Team 10 평가준비 보고서

CS492 카이스트 전산학부 특강<FE 개발>
팀원: 김건우, 김진석, 유홍범, 이주훈, 한승희

GitHub

Live Demo on Heroku

https://cs492c.herokuapp.com/

개요

Logo

코로나 19 사태로 인해 등교 수업이 제한되면서 온라인 수업과 오프라인 수업을 병행하는 블렌디드 러닝이 주목받고 있습니다. Blearn은 온라인과 오프라인의 장점을 결합한 블렌디드 러닝을 가능하게 하는 플랫폼입니다. Blearn에서는 사용자가 쉽게 수업을 생성 또는 가입하여 블렌디드 러닝 공간을 구성 및 참여할 수 있습니다. 오프라인 수업이 가진 이점들 중 두드러지는 것은 수업 중 공유된 학습 컨텐츠를 기반으로 원활한 상호 대화가 가능하다는 점입니다. 따라서 수업 내에서 동기화된 유튜브 영상 공유 및 제어와 음성/텍스트/이미지 채팅 기능을 구현했습니다. 또한 클래스 테마 변경, 파파고 API를 사용한 채팅 메시지 번역 등 학습에 용이한 기능을 구현했습니다.

UI 디자인

Component

Button

다른 컴포넌트에서 공통적으로 사용할 수 있는 버튼과 사용자가 버튼의 색상을 조정할 수 있는 테마를 구현했습니다.

Alert

여러 페이지에서 공통적으로 사용할 수 있는 Dropdown과 Dialog를 구현했습니다.

알림 메세지를 띄울 수 있는 Toast를 구현했습니다.

Chat

자신과 상대의 텍스트 / 사진 채팅을 구현했습니다.
텍스트 / 사진을 입력할 수 있는 input 컴포넌트를 구현했습니다.

Classroom

메인 페이지에 본인이 참여 중인 수업의 list와 수업에 참가하거나 수업을 생성할 수 있는 버튼을 구현했습니다.

수업에 참가하거나 수업을 생성하는 페이지를 구현했습니다.

수업 안에서 수업의 정보와 수업을 삭제하는 페이지를 구현했습니다.

Text Input

다양한 컴포넌트에서 공통으로 사용될 인풋 컴포넌트를 구현했습니다.

헤더 구성

  1. 메인 페이지 : 언어 변경과 알림, 그리고 내 정보를 보여주는 컴포넌트를 구현했습니다.

  1. 교실 페이지 : 교실의 멤버와 수업 설정, 언어 변경, 알림, 그리고 내 정보를 보여주는 컴포넌트를 구현했습니다.

푸터 구성
교실 페이지에서 멤버와 음성 채팅 상태를 표시하는 컴포넌트를 구현했습니다.

Profile

프로필을 설정할 수 있는 컴포넌트를 구현했습니다.

Voice

음성 채팅을 사용할 때 나타나는 오디오 비주얼라이저를 구현했습니다.

Youtube

방장이 컨트롤 할 수 있는 유튜브 플레이어를 구현하였습니다.

방 참가자가 사용할 수 있는 컨트롤러를 구현하였습니다.

실제 어플 실행 시 화면

Welcome

처음 웹앱에 접속했을 때와 로그아웃 시 보이는 페이지입니다. 헤더의 로그인 버튼을 누르면 네이버 / 깃허브 로그인 API로 연결됩니다.

Login

로그인 페이지입니다. 네이버 / 깃허브 계정으로 로그인 버튼을 눌러 로그인이 가능하고, 기존 사용자가 아닌 경우 가입 페이지로 연결됩니다.

main

메인 페이지입니다. 헤더에 있던 기존의 로그인 버튼이 사용자 계정 관리 / 테마 선택 버튼으로 변경됩니다. 사용자가 속한 수업의 list를 확인할 수 있고 수업 참가·생성하기 버튼으로 새로운 수업을 만들거나 참가할 수 있습니다.

Profile

헤더의 계정 관리 버튼에서 내 프로필 보기를 누르면 나타나는 프로필 변경 화면입니다. 프로필 사진과 사용자의 이름을 변경할 수 있고, 연결된 소셜 계정의 정보와 다른 소셜 계정을 연결할 수 있는 버튼이 있습니다.

Classroom

교실 페이지입니다. 헤더에는 유튜브 영상 세팅 버튼과 시작·종료 버튼, 수업 설정 버튼이 있습니다. 수업 설정 화면에서는 수업 정보를 수정하거나, 수업을 삭제할 수 있습니다.
메인 화면에는 유튜브 플레이어와 채팅, 채팅 인풋이 존재하고 푸터에는 수업에 참여한 사람들의 프로필 사진과 음성 메세지를 전달할 수 있는 말하기 버튼이 있습니다.

상태 관리

Frontend

Recoil

웹앱의 전역 상태 관리를 위해 Recoil을 사용하였습니다. Recoil을 사용하면 atoms (공유 상태)에서 selectors (순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있습니다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위입니다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환합니다.

classrooms

classroomsAtom : 현재 접속한 유저의 모든 classroom을 가지고 있는 state입니다. 유저가 로그인 했을 때 User DB에서 classrooms 데이터를 불러와 세팅됩니다.
classroomsByHashSelector : classroomHash를 이용하여 classroom을 찾고 정보를 변경할 수 있도록 만들어 주는 selector입니다. 수업 이름/비밀번호 변경, 수업 비디오 변경, 말하고 있는 사람 변경 등에 사용됩니다.
classroomsNewSelector : 새로운 classroom을 추가해주는 selector입니다. 수업 참여/생성 등에 사용됩니다.

loading

loadingAtom : 현재 로딩 여부를 나타내는 state입니다. 페이지를 띄우기 전이나 로그인 페이지에서 로딩 상태를 나타낼 때 사용합니다.

mainClassroomHashState

mainClassroomHashAtom : 유저의 classroom 중 현재 focus되어있는 classroom의 Hash를 가지고 있는 state입니다. header, footer, voice chat 등에서 수업의 정보를 얻는 데 이용됩니다.

me

meAtom : 유저의 정보를 가지고 있는 state입니다. 유저의 정보는 로그인 되었을 때 처음 세팅됩니다. 정보의 구성은 다음과 같습니다.

export interface MeInfo {
  stringId: string;
  displayName: string;
  profileImage: string;
  initialized: boolean;
  ssoAccounts: SSOAccountJSON[];
}

meInfoSelector : 유저의 정보를 제공, 변경해주는 selector입니다. 유저의 정보가 필요한 모든 상황 또는 로그인이 된 바로 다음이나 profile 변경 시 사용됩니다.
meIdSelector : 유저의 고유한 id를 제공하는 selector입니다. 유저가 방장인지 학생인지 확인할 때 주로 사용됩니다.
meAddSSOAccountSelector : 소셜 계정을 추가할 때 사용하는 selector입니다.
meRemoveSSOAccountSelector : 소셜 계정을 삭제할 때 사용하는 selector입니다.

screenSize

screenSizeAtom : 다양한 스크린 사이즈에 대응하기 위한 state입니다. MobilePortrait/ MobileLandscape/Desktop으로의 화면 변경 시 사용됩니다.

export interface ScreenSize {
  width: number;
  viewportHeight: number;
  actualHeight: number;
  offset: number;
}

theme

themeAtom : 웹앱의 색상 테마를 가지고 있는 state입니다. 테마의 종류는 violet, pink, green, blue이며, 이 값에 따라 Header, Footer, Logo, Button 등의 색상이 달라집니다.

toast

toastAtom : 알림 toast를 가지고 있는 state입니다.
toastNewSelector : 새로운 toast를 추가할 때 사용하는 selector입니다. BackEnd에서 오는 에러 메세지를 띄울 때 주로 사용합니다.

Backend

Classroom

User & Socket

Session

외부 API

앱 내 여러 기능은 외부 API 사용을 필요로 합니다. 대표적으로 로그인, 유튜브 공유 및 제어, 채팅 번역, 채팅 이미지 메시지 기능에서 외부 API 호출을 필요로 합니다.

네이버 아이디로 로그인 (OAuth 2.0)

네이버 로그인 API를 사용하기 위해 오픈 API 사용 신청에서 앱 및 제공 정보 등록 후 로그인 API 키를 발급 받았습니다.

GitHub OAuth

GitHub OAuth를 사용하기 위해 로그인 API 키를 발급 받았습니다.

YouTube API

수업 내 영상 공유와 제어를 위해 YouTube IFrame Player API를 react-youtube npm 패키지를 통해 사용했습니다.

파파고 번역

수업 내 채팅 메시지를 번역하여 보여주기 위하여 파파고 API를 사용했습니다. 해당 PR을 확인해주시길 바랍니다:

https://github.com/2021-fall-cs492c-team-10/monorepo/pull/228

Imgur

프로필 사진 및 채팅 이미지를 업로드하기 위해 외부 서비스인 Imgur를 이용했습니다. Imgur API에서 클라이언트 키를 발급받았습니다.

Imagga

사진을 분석하여 연관된 단어를 제시해 주는 이미지 태깅 기능을 이용하려고 불러온 API입니다. 이 서비스는 유저가 올린 채팅 이미지에 자동으로 alt text를 붙여주는 기능을 구현하기 위하여 사용되었습니다.

라이브러리화

frontend/utils

backend/utils

@team-10/lib

아래 API specification 섹션에서 설명하는 REST API와 Socket.IO interface의 request/response 타입 및 프론트엔드와 백엔드에서 공통적으로 쓰이는 타입들을 공통 라이브러리인 @team-10/lib으로 빼서 라이브러리화 하였습니다.

API Specification & 이벤트 관리

REST API

개요

백엔드 서버에서 사용하는 REST API로, prefix로 /api가 붙어 있습니다. Endpoint 타입으로 HTTP method와 경로를 concatenate한 string을 지정해 두었고, 이에 따라 path parameters와 body 타입이 strict하게 결정됩니다. (Query parameters는 높은 자유도를 위해 제한을 두지는 않았습니다.)

예를 들어, 아래는 /api/users/me/sso-accounts 아래에 있는 route들에 대한 REST API 타입입니다.

import { SSOAccountJSON } from '..';
import { Empty, Response } from '../..';

export type UsersMeSSOAccountsEndpoints =
  | 'GET /users/me/sso-accounts'
  | 'GET /users/me/sso-accounts/:provider'
  | 'DELETE /users/me/sso-accounts/:provider';
export type UsersMeSSOAccountsPathParams = {
  'GET /users/me/sso-accounts': Empty;
  'GET /users/me/sso-accounts/:provider': { provider: string };
  'DELETE /users/me/sso-accounts/:provider': { provider: string };
};
export type UsersMeSSOAccountsRequestBodyType = {
  'GET /users/me/sso-accounts': Empty;
  'GET /users/me/sso-accounts/:provider': Empty;
  'DELETE /users/me/sso-accounts/:provider': Empty;
};
export type UsersMeSSOAccountsResponseType = {
  'GET /users/me/sso-accounts': UsersMeSSOAccountsGetResponse;
  'GET /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderGetResponse;
  'DELETE /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderDeleteResponse;
};

Path parameters의 경우에는 'GET /users/me/sso-accounts/:provider'와 같이 colon-prefixed parameter name을 넣어 두었고, 그에 해당하는 PathParams 타입이 정의되어 있습니다. Request body와 response body 역시 각각의 endpoint에 대해 정의되어 있습니다.

공통 타입

REST API에서 공통적으로 사용되는 타입들입니다.

/// @team-10/lib/src/rest
export type Empty = Record<string, never>;

export type Response<P, E extends ResponseError> = SuccessResponse<P> | FailureResponse<E>;
export interface SuccessResponse<P> {
  success: true;
  payload: P;
}
export interface FailureResponse<E extends ResponseError> {
  success: false;
  error: UnauthorizedError | InternalServerError | E;
}
export interface ResponseError {
  code: string;
  statusCode: number;
  extra: Record<string, any>;
}
export interface UnauthorizedError extends ResponseError {
  code: 'UNAUTHORIZED';
  statusCode: 401;
  extra: Empty;
}
export interface InternalServerError extends ResponseError {
  code: 'INTERNAL_SERVER_ERROR';
  statusCode: 500;
  extra: {
    details?: string;
  }
}
export const unauthorizedError: UnauthorizedError = {
  code: 'UNAUTHORIZED',
  statusCode: 401,
  extra: {},
};


// @team-10/lib/src/rest/users
export const providers = ['naver' as 'naver', 'github' as 'github'];
export type Provider = typeof providers extends (infer T)[] ? T : never;

export interface SSOAccountJSON {
  provider: Provider;
  providerId: string;
}

export interface UserInfoJSON {
  stringId: string;
  displayName: string;
  profileImage: string;
}

/users/me (Authenticated)

자신에 대한 정보를 받거나 수정하는 route입니다.

/// @team-10/lib/src/rest/users/me
export type UsersMeEndpoints =
  | UsersMeSSOAccountsEndpoints
  | 'GET /users/me'
  | 'PATCH /users/me'
  | 'DELETE /users/me';
export type UsersMePathParams = UsersMeSSOAccountsPathParams & {
  'GET /users/me': Empty;
  'PATCH /users/me': Empty;
  'DELETE /users/me': Empty;
};
export type UsersMeRequestBodyType = UsersMeSSOAccountsRequestBodyType & {
  'GET /users/me': Empty;
  'PATCH /users/me': Partial<UserInfoJSON>;
  'DELETE /users/me': Empty;
};
export type UsersMeResponseType = UsersMeSSOAccountsResponseType & {
  'GET /users/me': UsersMeGetResponse;
  'PATCH /users/me': UsersMePatchResponse;
  'DELETE /users/me': UsersMeDeleteResponse;
};

// GET /users/me
type UsersMeGetResponse = Response<UserInfoMeJSON, never>;

// PATCH /users/me
export type UsersMePatchResponse = Response<UserInfoJSON, UsersMePatchError>;
export type UsersMePatchError = {
  code: 'INVALID_INFORMATION';
  statusCode: 400;
  extra: {
    field: string;
    details: string;
  };
};

// DELETE /users/me
type UsersMeDeleteResponse = Response<Empty, never>;

export interface UserInfoMeJSON extends UserInfoJSON {
  initialized: boolean;
  ssoAccounts: SSOAccountJSON[];
  classrooms: ClassroomJSON[];
}


/// @team-10/lib/src/rest/users/me/sso-accounts
import { SSOAccountJSON } from '..';
import { Empty, Response } from '../..';

export type UsersMeSSOAccountsEndpoints =
  | 'GET /users/me/sso-accounts'
  | 'GET /users/me/sso-accounts/:provider'
  | 'DELETE /users/me/sso-accounts/:provider';
export type UsersMeSSOAccountsPathParams = {
  'GET /users/me/sso-accounts': Empty;
  'GET /users/me/sso-accounts/:provider': { provider: string };
  'DELETE /users/me/sso-accounts/:provider': { provider: string };
};
export type UsersMeSSOAccountsRequestBodyType = {
  'GET /users/me/sso-accounts': Empty;
  'GET /users/me/sso-accounts/:provider': Empty;
  'DELETE /users/me/sso-accounts/:provider': Empty;
};
export type UsersMeSSOAccountsResponseType = {
  'GET /users/me/sso-accounts': UsersMeSSOAccountsGetResponse;
  'GET /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderGetResponse;
  'DELETE /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderDeleteResponse;
};

// GET /users/me/sso-accounts
export type UsersMeSSOAccountsGetResponse = Response<SSOAccountJSON[], never>;

// GET /users/me/sso-accounts/:provider
export type UsersMeSSOAccountsProviderGetResponse
  = Response<SSOAccountJSON, UsersMeSSOAccountsProviderGetError>;
export type UsersMeSSOAccountsProviderGetError = {
  code: 'UNSUPPORTED_PROVIDER';
  statusCode: 400;
  extra: Empty;
} | {
  code: 'NONEXISTENT_SSO_ACCOUNT';
  statusCode: 404;
  extra: Empty;
};

// DELETE /users/me/sso-accounts/:provider
export type UsersMeSSOAccountsProviderDeleteResponse
  = Response<Empty, UsersMeSSOAccountsProviderDeleteError>;
export type UsersMeSSOAccountsProviderDeleteError = {
  code: 'UNSUPPORTED_PROVIDER';
  statusCode: 400;
  extra: Empty;
} | {
  code: 'NONEXISTENT_SSO_ACCOUNT';
  statusCode: 400;
  extra: Empty;
} | {
  code: 'UNIQUE_SSO_ACCOUNT';
  statusCode: 400;
  extra: Empty;
};

/users/:othersId (Authenticated)

다른 유저의 정보를 가져오는 route입니다. UserInfoJSON에 더해 자신과 공통으로 속해 있는 수업을 반환합니다.

/// @team-10/lib/src/rest/users/other
import { Empty, Response } from '..';
import { ClassroomJSON } from '../../classroom';

import { UserInfoJSON } from '.';

export type UsersOtherEndpoints = 'GET /users/:id';
export type UsersOtherPathParams = {
  'GET /users/:id': { id: string };
};
export type UsersOtherRequestBodyType = {
  'GET /users/:id': Empty;
};
export type UsersOtherResponseType = {
  'GET /users/:id': UsersOtherGetResponse;
};

/* GET /users/:id */
export interface UserInfoOtherJSON extends UserInfoJSON {
  commonClassrooms: ClassroomJSON[];
}
export type UsersOtherGetResponse = Response<UserInfoOtherJSON, UserInfoOtherGetError>;
export type UserInfoOtherGetError = {
  code: 'NONEXISTENT_USER';
  statusCode: 404;
  extra: Empty;
};

/toasts

페이지 redirection을 고려해, 페이지가 다시 로드될 때 세션에 담겨 있던 토스트 메시지를 가져와서 렌더하기 위한 route입니다. 세션에 담겨 있던 토스트는 클라이언트가 이 경로로 접속하면 토스트 (Toast[])를 반환하고 세션에서 토스트를 비웁니다.

/// @team-10/lib/src/rest/toasts
import { Response, Empty } from '.';

export type ToastsEndpoints =
  | 'GET /toasts';
export type ToastsPathParams = {
  'GET /toasts': Empty;
};
export type ToastsRequestBodyType = {
  'GET /toasts': Empty;
};
export type ToastsResponseType = {
  'GET /toasts': ToastsGetResponse;
};

interface Toast {
  type: 'info' | 'warn' | 'error';
  message: string;
}
type ToastsGetResponse = Response<Toast[], never>;

/translate (Authenticated)

파파고 번역을 이용하기 위한 route입니다. 채팅 메시지에 대해서만 번역을 진행하며, 채팅 메시지의 uuid를 받아 대응되는 번역된 메시지를 반환합니다.

import { Response, Empty } from '.';

export type TranslateEndpoints =
  | 'GET /translate';
export type TranslatePathParams = {
  'GET /translate': Empty;
};
export type TranslateRequestBodyType = {
  'GET /translate': Empty;
};
export type TranslateResponseType = {
  'GET /translate': TranslateGetResponse;
};

type TranslateGetResponse = Response<{ message: string }, never>;

/youtube (Authenticated)

YouTube 검색 API를 이용하기 위한 route입니다.

import { Response, Empty } from '.';

export type YouTubeEndpoints =
  | 'GET /youtube';
export type YouTubePathParams = {
  'GET /youtube': Empty;
};
export type YouTubeRequestBodyType = {
  'GET /youtube': Empty;
};
export type YouTubeResponseType = {
  'GET /youtube': YouTubeGetResponse;
};

export type YouTubeGetResponse = Response<{
  result: YouTubeVideoDescription[];
  nextPageToken?: string;
}, YouTubeGetError>;
export interface YouTubeVideoDescription {
  thumbnail: string;
  title: string;
  publishedAt: string; // ISO string
  creator: string;
  video: { type: 'single' | 'playlist'; id: string };
}
export type YouTubeGetError = {
  code: 'INVALID_INFORMATION';
  statusCode: 400;
  extra: {
    field: string;
    details: string;
  };
};

Socket.IO API

개요

Socket.IO API의 이벤트 payload 타입은 세 가지로 나누었습니다:

소켓의 입장에서 ResponseBroadcast는 구분되지 않기 때문에, namespace Socket___.Events에는 이 둘을 통합하여 Response로 export합니다.

Socket.IO 기본 내장 namespace는 사용하지 않으며 (client socket을 여러 개 저장해야 하는 상황을 피하기 위해) 따라서 각 event name 앞에 prefix classroom/, voice/ 등을 붙여 구분합니다.

관련 Listener 파일

공통 타입

export type ClassroomHashFirst = 'B' | 'H' | 'J' | 'K' | 'L' | 'M' | 'N' | 'P' | 'S' | 'T';
export type ClassroomHashSecond = 'A' | 'E' | 'I' | 'O' | 'U';
export type ClassroomHashThird = 'K' | 'L' | 'M' | 'N' | 'P' | 'S' | 'T' | 'Z';
export type ClassroomHashSyllable = `${ClassroomHashFirst}${ClassroomHashSecond}${ClassroomHashThird}`;
// XXX: TypeScript is too limited to handle ClassroomHash type exactly:
//      Expression produces a union type that is too complex to represent.
// export type ClassroomHash =
//   `${ClassroomHashSyllable}-${ClassroomHashSyllable}-${ClassroomHashSyllable}`;
export type ClassroomHash = string;

export type DateNumber = number;

classroom/

import { ClassroomJSON } from '..';

import { ClassroomHash } from './common';

import { DateNumber } from '.';

export namespace SocketClassroom {
  export namespace Events {
    export interface Request {
      'classroom/Join': (params: SocketClassroom.Request.Join) => void;
    }
    export interface Response {
      'classroom/Join': (params: SocketClassroom.Response.Join) => void;
      'classroom/PatchBroadcast': (params: SocketClassroom.Broadcast.Patch) => void;
    }
  }

  export namespace Request {
    export type Join = JoinRequest;
  }
  export namespace Response {
    export type Join = JoinResponse;
  }
  export namespace Broadcast {
    export type Patch = PatchBroadcast;
  }

  export interface JoinRequest {
    hash: ClassroomHash;
  }
  export type JoinResponse =
    | ({ success: true; isVideoPlaying: boolean; videoTime: DateNumber | null } & ClassroomJSON)
    | {
      success: false;
      reason: typeof JoinFailReason[keyof typeof JoinFailReason];
    };
  export const JoinFailReason = {
    UNAUTHORIZED: -1 as -1,
    NOT_MEMBER: -2 as -2,
  };

  export interface PatchBroadcast {
    hash: ClassroomHash;
    patch: Partial<ClassroomJSON>;
  }
}

voice/

음성 채팅을 위한 namespace입니다.

import { ClassroomHash, DateNumber } from './common';

export namespace SocketVoice {
  export namespace Events {
    export interface Request {
      'voice/StateChange': (params: SocketVoice.Request.StateChange) => void;
      'voice/StreamSend': (params: SocketVoice.Request.StreamSend) => void;
    }

    export interface Response {
      'voice/StateChange': (params: SocketVoice.Response.StateChange) => void;
      'voice/StreamSend': (params: SocketVoice.Response.StreamSend) => void;
      'voice/StateChangeBroadcast': (params: SocketVoice.Broadcast.StateChange) => void;
      'voice/StreamReceiveBroadcast': (params: SocketVoice.Broadcast.StreamReceive) => void;
    }
  }

  export namespace Request {
    export type StateChange = StateChangeRequest;
    export type StreamSend = StreamSendRequest;
  }

  export namespace Response {
    export type StateChange = StateChangeResponse;
    export type StreamSend = StreamSendResponse;
  }

  export namespace Broadcast {
    export type StateChange = StateChangeBroadcast;
    export type StreamReceive = StreamReceiveBroadcast;
  }

  /* Request to use or stop using voice chat */
  export interface StateChangeRequest {
    hash: ClassroomHash;
    speaking: boolean;
  }
  export type StateChangeResponse =
    | StateChangeGrantedResponse
    | StateChangeDeniedResponse;
  export interface StateChangeGrantedResponse {
    success: true;
    speaking: boolean;
  }
  export interface StateChangeDeniedResponse {
    success: false;
    reason: typeof PermissionDeniedReason[keyof typeof PermissionDeniedReason];
  }
  export const PermissionDeniedReason = {
    UNAUTHORIZED: -1 as -1,
    NOT_MEMBER: -2 as -2,
    SOMEONE_IS_SPEAKING: 0 as 0,
  };
  export function permissionDeniedReasonAsMessage(
    reason: typeof PermissionDeniedReason[keyof typeof PermissionDeniedReason],
  ): string {
    return {
      [PermissionDeniedReason.UNAUTHORIZED]: '현재 로그아웃 상태입니다.',
      [PermissionDeniedReason.NOT_MEMBER]: '이 수업을 가르치거나 듣는 사람이 아닙니다.',
      [PermissionDeniedReason.SOMEONE_IS_SPEAKING]: '누군가 이미 이야기하고 있습니다.',
    }[reason];
  }

  /* Be broadcasted and subscribe voice chat state changes */
  export type StateChangeBroadcast =
    | StateChangeStartBroadcast
    | StateChangeEndBroadcast;
  export interface StateChangeStartBroadcast {
    hash: ClassroomHash;
    userId: string;
    speaking: true;
    sentAt: DateNumber;
  }
  export interface StateChangeEndBroadcast {
    hash: ClassroomHash;
    userId: string;
    speaking: false;
    reason: typeof StateChangeEndReason[keyof typeof StateChangeEndReason];
    sentAt: DateNumber;
  }
  export const StateChangeEndReason = {
    NORMAL: 0 as 0,
    SESSION_EXPIRED: -1 as -1,
    CONNECTION_LOST: -2 as -2,
    INTERRUPTED_BY_INSTRUCTOR: -3 as -3,
  };
  export function stateChangeEndReasonAsMessage(
    reason: typeof StateChangeEndReason[keyof typeof StateChangeEndReason],
  ): string {
    return {
      [StateChangeEndReason.NORMAL]: '말하기가 정상적으로 종료되었습니다.',
      [StateChangeEndReason.SESSION_EXPIRED]: '세션이 만료되었습니다.',
      [StateChangeEndReason.CONNECTION_LOST]: '말씀하시는 분의 접속이 끊겼습니다.',
      [StateChangeEndReason.INTERRUPTED_BY_INSTRUCTOR]: '강의자에 의해 말하기가 종료되었습니다.',
    }[reason];
  }

  /* Send voices while talking */
  export interface StreamSendRequest {
    hash: ClassroomHash;
    voices: Voice[];
    sequenceIndex: number;
  }
  export type StreamSendResponse =
  | StreamSendGrantedResponse
  | StreamSendDeniedResponse;
  export interface StreamSendGrantedResponse {
    success: true;
    sequenceIndex: number;
  }
  export interface StreamSendDeniedResponse {
    success: false;
    reason: typeof StreamSendDeniedReason[keyof typeof StreamSendDeniedReason];
  }
  export const StreamSendDeniedReason = {
    UNAUTHORIZED: -1 as -1,
    NOT_MEMBER: -2 as -2,
    NOT_SPEAKER: -5 as -5,
  };
  export function streamSendDeniedReasonAsMessage(
    reason: typeof StreamSendDeniedReason[keyof typeof StreamSendDeniedReason],
  ): string {
    return {
      [StreamSendDeniedReason.UNAUTHORIZED]: '현재 로그아웃 상태입니다.',
      [StreamSendDeniedReason.NOT_MEMBER]: '이 수업을 가르치거나 듣는 사람이 아닙니다.',
      [StreamSendDeniedReason.NOT_SPEAKER]: '말하기 권한이 없습니다.',
    }[reason];
  }

  /* Received voices */
  export interface StreamReceiveBroadcast {
    speakerId: string;
    voices: Voice[];
    sequenceIndex: number;
  }
  export interface Voice {
    type: 'opus' | 'mpeg';
    buffer: ArrayBuffer;
  }
}

youtube/

유튜브 비디오를 공유하기 위한 namespace입니다.

import { YouTubeVideo } from '..';

import { ClassroomHash } from './common';

export namespace SocketYouTube {
  export namespace Events {
    export interface Request {
      'youtube/ChangePlayStatus': (params: SocketYouTube.Request.ChangePlayStatus) => void;
    }
    export interface Response {
      'youtube/ChangePlayStatus': (params: SocketYouTube.Response.ChangePlayStatus) => void;
      'youtube/ChangePlayStatusBroadcast': (params: SocketYouTube.Broadcast.ChangePlayStatus) => void;
    }
  }

  export namespace Request {
    export type ChangePlayStatus = ChangePlayStatusRequest;
  }
  export namespace Response {
    export type ChangePlayStatus = ChangePlayStatusResponse;
  }
  export namespace Broadcast {
    export type ChangePlayStatus = ChangePlayStatusBroadcast;
  }

  // send play or stop requset
  export interface ChangePlayStatusRequest {
    hash: ClassroomHash;
    play: boolean;
    video: YouTubeVideo | null;
    time: number | null;
  }

  // play status response
  export type ChangePlayStatusResponse =
    | ChangePlayStatusSuccessResponse
    | ChangePlayStatusFailResponse;

  export interface ChangePlayStatusSuccessResponse {
    success: true;
    play: boolean;
  }
  export interface ChangePlayStatusFailResponse {
    success: false;
    reason: typeof ChangePlayStatusFailReason[keyof typeof ChangePlayStatusFailReason];
  }
  export const ChangePlayStatusFailReason = {
    UNAUTHORIZED: -1 as -1,
    NOT_MEMBER: -2 as -2,
    PERMISSION_DENIED: -3 as -3,
  };

  // play status Broadcast
  export interface ChangePlayStatusBroadcast {
    hash: ClassroomHash;
    play: boolean;
    videoId: string | null;
    time: number | null;
  }
}

GitHub 기록

commit 횟수

총 376회의 commit: https://github.com/2021-fall-cs492c-team-10/monorepo/graphs/commit-activity

PR 리뷰 수

총 52개의 리뷰가 완성되었습니다. GitHub PR 탭에서 확인 부탁드립니다.

README 관리

wiki 문서화

SSL 서버 여는 법

https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/SSL-서버-여는-법

디자인

https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/디자인

레포 구조

https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/레포-구조

레포 세팅

https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/레포-세팅

커밋 규칙

https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/커밋-규칙